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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ Release with new features and bugfixes:
* https://github.com/devonfw/IDEasy/issues/1884[#1884]: Fix java unzipping losing symlink information
* https://github.com/devonfw/IDEasy/issues/1716[#1716]: Add commandlet for Claude Code CLI
* https://github.com/devonfw/IDEasy/issues/1844[#1844]: Fix vscode installation hanging indefinitely in WSL Linux environments
* https://github.com/devonfw/IDEasy/issues/1953[#1953]: Implement base functionality of cleanup commandlet
* https://github.com/devonfw/IDEasy/issues/863[#863]: Mac x64 / ARM android-studio 2024 releases missing — accept .dmg downloads alongside .zip
* https://github.com/devonfw/IDEasy/issues/1904[#1904]: Add Inso CLI to IDEasy commandlets
* https://github.com/devonfw/IDEasy/issues/1952[#1952]: Ability for platform specific dependencies
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,357 @@
package com.devonfw.tools.ide.commandlet;

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

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.devonfw.tools.ide.cli.CliAbortException;
import com.devonfw.tools.ide.context.IdeContext;
import com.devonfw.tools.ide.log.IdeLogLevel;
import com.devonfw.tools.ide.property.FlagProperty;
import com.devonfw.tools.ide.step.Step;

/**
* Commandlet which scans your IDE installation for unused software (tools not currently used by any project) and removes them.
*/
public class CleanupCommandlet extends Commandlet{

public final FlagProperty forceDelete;

private static final Logger LOG = LoggerFactory.getLogger(CleanupCommandlet.class);

/**
* Class which represents individual IDE tools. Contains multiple parameter such as the name of the tool, the path where it is installed,
* a list of projects which use this tool and a boolean whether the tool is marked for deletion or not.
* Furthermore, it contains a list of editions of the edition (e.g. for intellij, we could have the editions "intellij" or "ultimate").
Comment on lines +26 to +28
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This JavaDoc does not match the implementation.
I see that it contains a List usedBy and guess this is something (the project name???) where this IdeToolEditionVersion is used.
In general it is better to first design a real API with JavaDoc.
There you document the different properties via their getter JavaDoc.
If you end up putting everything into the type JavaDoc this easily gets outdated and inconsistent.

*/
private static class IdeToolEditionVersion {
String versionName;
private final Path path;
private final List<String> usedBy = new java.util.ArrayList<>();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

use import statements:

Suggested change
private final List<String> usedBy = new java.util.ArrayList<>();
private final List<String> usedBy = new ArrayList<>();

I only use qualified names for situations where I really want to defer classloading because maybe the type is optional and I maybe want to catch ClassNotFoundException or NoClassDefFoundError.
Therefore I always get confused when I see FQN (fully qualified names) in regular situations.

private boolean delete = false;

IdeToolEditionVersion(String versionName, Path path) {
this.versionName = versionName;
this.path = path;
}
}

/**
* Class which represents individual editions of an IDE tool. Contains multiple parameter such as the name of the edition, the path where it is installed,
* a list of projects which use this edition and a boolean whether the edition is marked for deletion or not.
* Furthermore, it contains a list of versions of the edition (e.g. for intellij ultimate edition, we could have version 2022.3 and 2023.1 installed).
*/
private static class IdeToolEdition {
String editionName;
private final Path path;
private final List<String> usedBy = new java.util.ArrayList<>();
private boolean delete = false;
private final List<IdeToolEditionVersion> versions = new java.util.ArrayList<>();

IdeToolEdition(String editionName, Path path) {
this.editionName = editionName;
this.path = path;
}
}

/**
* Class which represents individual versions of an edition of an IDE tool. Contains multiple parameter such as the name of the version, the path where it is installed,
* a list of projects which use this version and a boolean whether the version is marked for deletion or not.
*/
private static class IdeTool {
String toolName;
private final Path path;
private final List<String> usedBy = new java.util.ArrayList<>();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I am slightly confused why we have this usedBy in all 3 inner classes.
If we really want to do that, we could even use OOP and derive the 3 classes from an abstract class with a getUsedBy() and a nice centralised JavaDoc that is then inherited and reused by all 3 classes.
But IMHO we only need to track what tool+edition+version combination is used.
If all unused ones are deleted and we end up with en empty edition folder this does not really waste disc-space and we can still check that after deletion we also delete the parent folder if it became empty (and if we delete the edition folder we can also delete the version folder if that is now also empty).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

BTW: Generic types like String or Number always need explanation. So if you see List<String> you do not know what this is unless you have further JavaDoc or other hints.
If you instead see List<IdeProject> and IdeProject is a class or record with JavaDoc then things immediately get clear. Such IdeProject type could wrap the project name as String but also maybe contain the Path to the project and later could even have methods like getWorkspaces() or whatever - see what @laim2003 is currently implementing for the GUI.

private boolean delete = false;
private final List<IdeToolEdition> editions = new java.util.ArrayList<>();

IdeTool(String toolName, Path path) {
this.toolName = toolName;
this.path = path;
}
}
Comment on lines +25 to +75
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I would recommend to move this to top-level classes (allowing them to become public API that we can later reuse in the GUI so the user can see a Popup with a tree and tick or untick checkboxes.
Anyway if you keep inner classes follow the general conventions for Java types:

public [class|interface|record|enum|@interface] <name> ... {
  // constants

  // fields / member variables

  // constructors

  // methods while generic Object methods like equals/hashCode/toString usually go to the end

  // inner types
}

So move them to the end what makes the code easier to read for typical Java coders used to that convention.


// List of installed IDE tools in the global software folder at $IDE_ROOT/_ide/software/default. This list is populated at the beginning of the cleanup process and then used to identify unused software and delete it.
private final List<IdeTool> installedIdeTools = new java.util.ArrayList<>();
Comment on lines +77 to +78
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I would rather design this stateless since there is no need to make it stateful and when this is run in the GUI we might one day end up with concurrency issues.
To make it stateless simple convert this to a local variable and where required to a parameter passed to the methods.


public CleanupCommandlet(IdeContext context) {

super(context);
addKeyword(getName());
this.forceDelete = add(new FlagProperty("--fd")); // Force-Delete flag: Skips confirmation prompts if provided
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

POSIX standard defines options in two forms:

  • short option have only a single character -f, -d and are typically used by experts who know the CLI very well and are lazy and want to save typing work. These options can also be combined: -fd is the same as -f -d.
  • long options start with two dashes and should be long and self explanatory. This is used e.g. in scripts to make it easier to understand for the reader what is going on here so instead of -fd we can also write --force --debug and newbie users will be happy since they can way easier understand what is going on.
Suggested change
this.forceDelete = add(new FlagProperty("--fd")); // Force-Delete flag: Skips confirmation prompts if provided
this.forceDelete = add(new FlagProperty("--force-delete")); // Skips confirmation prompts if provided

Please note that obviously you also need to update the help texts again when applying this change.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

BTW: are you aware that you can already do the same without adding this property?
ide -f cleanup or ide --force cleanup will run the cleanup in force mode and the askToContinue method will be automatically confirmed then... So if you want to follow KISS, you could even omit this property.

}


@Override
public String getName() {

return "cleanup";
}

@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(() -> {discoverAndDeleteUnusedSoftware();});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
step.run(() -> {discoverAndDeleteUnusedSoftware();});
step.run(this::discoverAndDeleteUnusedSoftware);


// Clear Array Lists so tools are not duplicated when running "ide cleanup" repeatedly in the ide shell
this.installedIdeTools.clear();

LOG.debug("Finished cleanup commandlet");
}

/**
* This method specified 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 folder and save installed software to a list
discoverInstalledSoftware(this.context.getIdeRoot().resolve("_ide/software/default"));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can be improved by reusing more dedicated method and constants (may require import statement to apply suggestion):

Suggested change
discoverInstalledSoftware(this.context.getIdeRoot().resolve("_ide/software/default"));
discoverInstalledSoftware(this.context.getSoftwareRepositoryPath().resolve(ToolRepository.ID_DEFAULT));


// Scan for IDEasy projects
List<Path> ideasyProjects = this.context.getFileAccess().listChildren(this.context.getIdeRoot(), Files::isDirectory);

// Iterate through IDEasy projects and scan software in software folder. Save found software to list
for (Path ideasyProject : ideasyProjects) {
if (ideasyProject.getFileName().toString().equals("_ide")) {
continue;
}
discoverUsedSoftware(ideasyProject.resolve("software"), ideasyProject.getFileName().toString());
discoverUsedSoftware(ideasyProject.resolve("software/extra"), ideasyProject.getFileName().toString());
Comment on lines +124 to +125
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reuse constants (this also allows to find usages in the workspace via IDE to see where in the code we are accessing this folder):

Suggested change
discoverUsedSoftware(ideasyProject.resolve("software"), ideasyProject.getFileName().toString());
discoverUsedSoftware(ideasyProject.resolve("software/extra"), ideasyProject.getFileName().toString());
Path ideasyProjectSoftware = ideasyProject.resolve(IdeContext.FOLDER_SOFTWARE);
discoverUsedSoftware(ideasyProjectSoftware, ideasyProject.getFileName().toString());
discoverUsedSoftware(ideasyProjectSoftware.resolve(IdeContext.FOLDER_EXTRA), ideasyProject.getFileName().toString());

}

// Mark unused software for deletion
markUnusedSoftwareForDeletion();
// Log summary report and proceed with deletion if user confirms
logSoftwareToBeDeleted();
}

/**
* This method discovers all installed tools at $IDE_ROOT/_ide/software/default and saves them to the list of installed tools.
* Installed editions are then recusively discovered
* @param software_folder The folder where the software is saved in ($IDE_ROOT/_ide/software/default)
*/
private void discoverInstalledSoftware(Path software_folder) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

In Java we do not name variables with underscore_case but camlCase.

Suggested change
private void discoverInstalledSoftware(Path software_folder) {
private void discoverInstalledSoftware(Path softwareFolder) {

List<Path> subfolders = this.context.getFileAccess().listChildren(software_folder, Files::isDirectory);
for (Path subfolder : subfolders) {
IdeTool tool = new IdeTool(subfolder.getFileName().toString(), this.context.getFileAccess().toRealPath(subfolder));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this.installedIdeTools.add(tool);
discoverInstalledEditions(subfolder, tool);
}
}

/**
* This method discovers all installed editions of a tool at $IDE_ROOT/_ide/software/default/<tool> and saves them to the edition list of the tool.
* Installed versions of the edition are then recusively discovered
* @param edition_folder The folder where the editions are saved in ($IDE_ROOT/_ide/software/default/<tool>)
* @param tool The respective tool for which we are discovering editions
*/
private void discoverInstalledEditions(Path edition_folder, IdeTool tool) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

use editionFolder.

List<Path> subfolders = this.context.getFileAccess().listChildren(edition_folder, Files::isDirectory);
for (Path subfolder : subfolders) {
IdeToolEdition edition = new IdeToolEdition(subfolder.getFileName().toString(), this.context.getFileAccess().toRealPath(subfolder));
tool.editions.add(edition);
discoverInstalledVersions(subfolder, edition);
}
}

/**
* This method discovers all installed versions of an edition of a tool at $IDE_ROOT/_ide/software/default/<tool>/<edition> and saves them to the version list of the edition.
* @param version_folder The folder where the versions are saved in ($IDE_ROOT/_ide/software/default/<tool>/<edition>)
* @param edition The respective edition for which we are discovering versions
*/
private void discoverInstalledVersions(Path version_folder, IdeToolEdition edition) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

and versionFolder.

List<Path> subfolders = this.context.getFileAccess().listChildren(version_folder, Files::isDirectory);
for (Path subfolder : subfolders) {
IdeToolEditionVersion version = new IdeToolEditionVersion(subfolder.getFileName().toString(), this.context.getFileAccess().toRealPath(subfolder));
edition.versions.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 software_folder The software folder of the IDEasy project to scan for used software
* @param project_name The name of the project we are currently scanning
*/
private void discoverUsedSoftware(Path software_folder, String project_name) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

please get used to camlCase for all Java variables (except for constants in UPPER_CASE).

// Get all installed tools for this project
List<Path> subfolders = this.context.getFileAccess().listChildren(software_folder, Files::isDirectory);
for (Path current_folder : 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/default
current_folder = this.context.getFileAccess().toRealPath(current_folder);
// Check if directory contains a software version file. If so, read version and add to list of used software.
if (Files.exists(current_folder.resolve(IdeContext.FILE_SOFTWARE_VERSION))
|| Files.exists(current_folder.resolve(IdeContext.FILE_LEGACY_SOFTWARE_VERSION))) {
if (!current_folder.startsWith(this.context.getIdeRoot().resolve("_ide/software/default"))) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

same here as in line 114

// We found a software that is locally installed in an IDEasy project but not in the global software folder. We leave these alone.
continue;
}
// Get details of the tool (name, edition, version)
String tool_name = current_folder.getParent().getParent().getFileName().toString();
String tool_edition = current_folder.getParent().getFileName().toString();
String tool_version = current_folder.getFileName().toString();
// Check if software exists in global IdeTool list. If so, mark as used
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nice to have but would be easier to read when extracting local variables.
So something like this:

Suggested change
// Check if software exists in global IdeTool list. If so, mark as used
// currentFolder has the structure «repo-path»/«tool»/«edition»/«version»
String toolVersion = currentFolder.getFileName().toString();
Path toolEditionFolder = currentFolder.getParent();
String toolEdition = toolEditionFolder.getFileName().toString();
String toolName = toolEditionFolder.getParent().getFileName().toString();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Somehow GitHub messed the line markers. I wanted to suggest this for 196-199.

for (IdeTool tool : this.installedIdeTools) {
if (tool.toolName.equals(tool_name)) {
tool.usedBy.add(project_name);
for (IdeToolEdition edition : tool.editions) {
if (edition.editionName.equals(tool_edition)) {
edition.usedBy.add(project_name);
for (IdeToolEditionVersion version : edition.versions) {
if (version.versionName.equals(tool_version)) {
version.usedBy.add(project_name);
break;
}
}
break;
}
}
break;
}
}
}
}
}

/**
* Sets the delete flag for all unused tools, editions, and versions to true.
*/
private void markUnusedSoftwareForDeletion() {
for (IdeTool tool : this.installedIdeTools) {
for (IdeToolEdition edition : tool.editions) {
for (IdeToolEditionVersion version : edition.versions) {
if (version.usedBy.isEmpty()) {
version.delete = true;
}
}
if (edition.usedBy.isEmpty()) {
edition.delete = true;
}
}
if (tool.usedBy.isEmpty()) {
tool.delete = true;
}
}
}

/**
* Generates a summary report for tools, editions, and versions to be deleted and prompts the user for confirmation.
* If the user agress, we proceed with deletion of the unused tools, editions, and version.
*/
private void logSoftwareToBeDeleted() {
String LogOutput = "";
int totalToolsDeleted = 0;
int totalEditionsDeleted = 0;
int totalVersionsDeleted = 0;
for (IdeTool tool : this.installedIdeTools) {
String LogOutputEdition = "";
int editionsDeleted = 0;
for (IdeToolEdition edition : tool.editions) {
String LogOutputVersion = "";
int versionsDeleted = 0;
for (IdeToolEditionVersion version : edition.versions) {
if (version.delete) {
LogOutputVersion += "\t\t - " + version.versionName + "\n";
versionsDeleted++;
totalVersionsDeleted++;
}
}
if (!LogOutputVersion.isBlank()) {
// If at least one version of the edition should be deleted
if (versionsDeleted < edition.versions.size()) {
LogOutputVersion += "\t\t + " + (edition.versions.size() - versionsDeleted) + " more version(s) of this edition will not be deleted\n";
}
LogOutputEdition += "\t - " + edition.editionName + "\n" + LogOutputVersion;
editionsDeleted++;
totalEditionsDeleted++;
}
}
if (!LogOutputEdition.isBlank()) {
// If at least one edition of the tool should have a delete operation
if (editionsDeleted < tool.editions.size()) {
LogOutputEdition += "\t + " + (tool.editions.size() - editionsDeleted) + " more edition(s) of this tool will not be deleted\n";
}
LogOutput += " - " + tool.toolName + "\n" + LogOutputEdition;
totalToolsDeleted++;
}
}

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 tools will be deleted: \n" + LogOutput);
LOG.info("Summary: {} installed tool versions across {} editions of {} tools will be deleted.", totalVersionsDeleted, totalEditionsDeleted, totalToolsDeleted);

// Ask for conformation. Skipped if --fd flag is provided
if (!this.forceDelete.isTrue()) {
try {
this.context.askToContinue("Do you want to continue?");

} catch (CliAbortException e) {
LOG.info("Installed Tools will not be deleted.");
return;
}
}
deleteUnusedSoftware();
}
}

/**
* Deletes tools, editions, and versions marked for deletion.
*/
private void deleteUnusedSoftware() {
int failed_deletion = 0;
// Delete the tool
for (IdeTool tool : this.installedIdeTools) {
if (tool.delete) {
LOG.debug("Deleting tool {} and all its editions and versions in {}", tool.toolName, tool.path);
failed_deletion += deleteFolder(tool.path);
continue;
}
// Delete editions of the tool
for (IdeToolEdition edition : tool.editions) {
if (edition.delete) {
LOG.debug("Deleting edition {} of tool {} and all its versions in {}", edition.editionName, tool.toolName, edition.path);
failed_deletion += deleteFolder(edition.path);
continue;
}
// Delete versions of the edition
for (IdeToolEditionVersion version : edition.versions) {
if (version.delete) {
LOG.debug("Deleting version {} of edition {} of tool {} in {}", version.versionName, edition.editionName, tool.toolName, version.path);
failed_deletion += deleteFolder(version.path);
}
}
}
}

// Log completion message
if (failed_deletion > 0) {
LOG.warn("Unused tools have been deleted.\nFailed to delete {} tools/editions/versions. Please check the log for details.", failed_deletion);
} else {
IdeLogLevel.SUCCESS.log(LOG, "Unused tools have been deleted successfully.");
}
}

/**
* 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,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));
Expand Down
3 changes: 3 additions & 0 deletions cli/src/main/resources/nls/Help.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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.
cmd.complete=Internal commandlet for bash auto-completion.
cmd.complete.detail=Run 'ide complete <args>' to activate the non-interactive autocompletion, replace <args> 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.
Expand Down Expand Up @@ -159,6 +161,7 @@ icd-hint=Hint: Use 'icd' command to easily navigate between your IDE home, proje
opt.--batch=enable batch mode (non-interactive).
opt.--code=clone given code repository containing a settings folder into workspaces so that settings can be committed alongside code changes.
opt.--debug=enable debug logging.
opt.--fd=Delete tools without confirmation
opt.--force=enable force mode.
opt.--force-plugin-reinstall=resets installed plugins to the project configuration
opt.--force-plugins=force plugin (re)installation.
Expand Down
Loading
Loading