#1953: Complete cleanup commandlet implementation and review handoverShow more lines - #2230
#1953: Complete cleanup commandlet implementation and review handoverShow more lines#2230Caylipp wants to merge 34 commits into
Conversation
…p-commandlet-handover
- scan all global software repositories - detect used versions by resolved installation paths - support links to installation subfolders and nested extra tools - make cleanup discovery stateless - track project usage only on version level - reuse the global force mode for confirmation - add regression tests for used and unused installations
Coverage Report for CI Build 30540908755Coverage increased (+0.2%) to 72.793%Details
Uncovered ChangesNo uncovered changes found. Coverage Regressions8 previously-covered lines in 2 files lost coverage.
Coverage Stats💛 - Coveralls |
hohwille
left a comment
There was a problem hiding this comment.
We are closing the finishing line 👍
Still I found some points...
| private final Path path; | ||
|
|
||
| /** A flag indicating whether the tool is marked for deletion. */ | ||
| private boolean delete; |
There was a problem hiding this comment.
Seems some of my review feedback got lost (was maybe set to resolved by original author):
Please use OOP and avoid pointless redundancies.
We have these 3 classes that all should have a common abstract super-class.
Each of them has
name(simply generalize to justnameinstead oftoolName,editionName,versionName)pathdelete
Also include methods like getters with JavaDoc, setter, etc.
BTW: I also suggested a simplification to have delete flag only in CleanupIdeToolEditionVersion so the consistency of the state gets trivial while with the current design it is complex. E.g. assume we later reuse this in the GUI and the user may see this as a tree with checkboxes and wants to unselect something manually to prevent deletion and then the parent deletion flag would need to be updated.
Simply when you iterate over the structure to perform the actual deletion check if the parent directory (edition or tool) now is empty after deletion of children and then also delete it.
There was a problem hiding this comment.
Thanks. I implemented with a common superclass, version level deletion state and cleanup of empty parent folders.
| List<CleanupIdeTool> installedCleanupIdeTools = discoverInstalledSoftware(); | ||
|
|
||
| // Scan for IDEasy projects | ||
| List<Path> ideasyProjects = this.context.getFileAccess().listChildren(this.context.getIdeRoot(), Files::isDirectory); |
There was a problem hiding this comment.
We have the same logic also in our GUI. It would make sense to add a method IdeContext.findProjects() for this.
And as a motivation why I always insist on creating reusable structures:
This implementation is incorrect. It will also include _ide as well as folders somehow created by the end-user that are not really IDEasy projects. When we create a method and once get it right, then we can reuse it everywhere. Even if there still is a bug, we can fix it in once place instead of multiple places because of copy&paste.
There was a problem hiding this comment.
Thanks, implemented and reused in both CleanupCommandlet and the GUI.
| if (ideasyProject.getFileName().toString().equals("_ide")) { | ||
| continue; | ||
| } |
There was a problem hiding this comment.
This would then be part of the central method.
There was a problem hiding this comment.
Thanks, this is now handled centrally by findProjects().
| Path softwareRepositoryPath = this.context.getSoftwareRepositoryPath(); | ||
| List<Path> repositoryFolders = this.context.getFileAccess().listChildren(softwareRepositoryPath, Files::isDirectory); | ||
| for (Path repositoryFolder : repositoryFolders) { | ||
| discoverInstalledSoftwareRepository(installedCleanupIdeTools, repositoryFolder); |
There was a problem hiding this comment.
This is correct for default and maven but not for custom-tools:
https://github.com/devonfw/IDEasy/blob/main/documentation/software.adoc#custom-tools
For the given example URL
https://some-file-server.company.com/projects/my-project
We would have some-file-server.company.com as repositoryFolder here, but the actual repo with tool/edition/version structure is in the sub-path projects/my-project.
Sorry for this extra complication. I also missed this point in the review of the old PR.
So IMHO we should instead explicitly go to subfolders ToolRepository.ID_DEFAULT and MvnRepository.ID as well as this.context.getCustomToolRepository().getId(). The latter ID may be custom and that folder will typically not exist. Anyhow code should always be robust and handle non existing directories even when expected (I could delete entire _ide/software folder and that should not break ide cleanup - actually ide update should automatically repair my project if I did such evil deletion manually).
There was a problem hiding this comment.
I implemented for default, Maven, and the full custom repository path. Missing folders are skipped.
| installedCleanupIdeTools.add(tool); | ||
| discoverInstalledEditions(toolFolder, tool); |
There was a problem hiding this comment.
Just as a thought, the methods could return a boolean, if some tool/edition/version was found that looks sane. Then you could skip adding if nothing sane was found like this:
boolean success = discoverInstalledEditions(toolFolder, tool);
if (success) {
installedCleanupIdeTools.add(tool);
}
I am just still thinking if we have a reliable way of detecting "sanity".
We could obviously check for existence of .ide.software.version what would be my preferred way, but assuming this file was deleted by accident (see also #2197) it would IMHO still make sense to delete the tool installation if unused.
Then I was thinking that version folder should have a name that is a valid version.
However, we have seen some tools violating our own rules for what we think is a valid version.
Also checking that there is at least one digit in the version is wrong since we have version latest.
Maybe checking for latest or "at least one digit" might make sense.
So far just my thoughts - we can still ignore this for now and consider as improvement after the merge.
There was a problem hiding this comment.
I left this for a potential followup as suggested.
There was a problem hiding this comment.
Thanks for picking this up the substance of the #1957 review is addressed and the new tests cover exactly the cases that were requested there. I went through the branch and found a few points before this can go in, three of them blocking from my side:
- the new tests error out on Windows without symlink privileges (verified locally, 3 of 4 tests)
- the CHANGELOG entry landed in an already released section
- the confirmation cannot actually be skipped with
-falone
The remaining ones are smaller correctness and consistency points. I left out anything already covered by the review above (common super class for the model classes, delete flag only on version level, central findProjects()).
| void testCleanupDeletesUnusedAndKeepsUsedGlobalSoftware() throws IOException { | ||
|
|
||
| // arrange | ||
| IdeTestContext context = newContext(PROJECT_BASIC); |
There was a problem hiding this comment.
These tests fail on Windows without symlink privileges (admin or Developer Mode). Verified locally with mvn -Dtest=CleanupCommandletTest test:
java.nio.file.FileSystemException: ...\software\cleanup-test-java: A required privilege is not held by the client
at CleanupCommandletTest.createSoftwareLink(CleanupCommandletTest.java:187)
[ERROR] Tests run: 4, Failures: 0, Errors: 3, Skipped: 0
According to AGENTS.md, tests that create symbolic links must call WindowsSymlinkTestHelper.assumeSymlinksSupported() at the beginning of the test method, so they are skipped gracefully instead of erroring (see FileAccessImplTest for the pattern).
| IdeTestContext context = newContext(PROJECT_BASIC); | |
| WindowsSymlinkTestHelper.assumeSymlinksSupported(); | |
| IdeTestContext context = newContext(PROJECT_BASIC); |
The same is needed in testCleanupKeepsVersionReferencedBySubdirectory and testCleanupKeepsVersionReferencedByNestedExtraTool, plus the import of com.devonfw.tools.ide.io.WindowsSymlinkTestHelper.
| * 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 |
There was a problem hiding this comment.
This entry was added to the == 2026.05.001 section (lines 83-129), which is already released. The currently open section is == 2026.08.001 at the top of the file.
| * https://github.com/devonfw/IDEasy/issues/1953[#1953]: Implement base functionality of cleanup commandlet |
Please add it under == 2026.08.001 instead:
* https://github.com/devonfw/IDEasy/issues/1953[#1953]: Implement base functionality of cleanup commandlet
| 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. |
There was a problem hiding this comment.
The issue asks for a flag that skips the confirmation prompt, and dropping --force-delete in favour of the global force mode is the right call. However -f alone does not skip it: AbstractIdeContext.displayOptionsAndGetAnswer only auto-confirms when isBatchMode() && isForceMode(), and ContextCommandlet sets both flags independently (setBatchMode(...) / setForceMode(...)). So users actually need ide -b -f cleanup.
Since this is the documented way to fulfil the requirement from #1953, it should be mentioned in the help text:
| cmd.cleanup.detail=This will remove any installed tools that are currently not in use by an IDEasy project. | |
| 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. |
Help_de.properties:15 needs the same addition. Also, all four tests currently confirm via context.setAnswers("yes"), so the force path is untested, a test for it would be good.
| @Override | ||
| public String getName() { | ||
|
|
||
| return "cleanup"; | ||
| } |
There was a problem hiding this comment.
cleanup operates on IDE_ROOT and not on a single project, but isIdeHomeRequired() defaults to true, so ide cleanup outside of a project aborts with NO_IDE_HOME (see AbstractIdeContext:1391). CreateCommandlet has the same characteristic and overrides it:
| @Override | |
| public String getName() { | |
| return "cleanup"; | |
| } | |
| @Override | |
| public String getName() { | |
| return "cleanup"; | |
| } | |
| @Override | |
| public boolean isIdeHomeRequired() { | |
| return false; | |
| } |
| /** | ||
| * This method discovers all installed editions of a tool at $IDE_ROOT/_ide/software/<repository>/<tool> and saves them to the edition list of the tool. | ||
| * Installed versions of the edition are then recursively discovered. | ||
| * | ||
| * @param editionFolder The folder where the editions are saved in ($IDE_ROOT/_ide/software/<repository>/<tool>). | ||
| * @param tool The respective tool for which we are discovering editions. | ||
| */ | ||
| private void discoverInstalledEditions(Path editionFolder, CleanupIdeTool tool) { | ||
| List<Path> subfolders = this.context.getFileAccess().listChildren(editionFolder, Files::isDirectory); | ||
| for (Path subfolder : subfolders) { | ||
| CleanupIdeToolEdition edition = new CleanupIdeToolEdition(subfolder.getFileName().toString(), this.context.getFileAccess().toRealPath(subfolder)); | ||
| tool.getEditions().add(edition); | ||
| discoverInstalledVersions(subfolder, edition); | ||
| } | ||
| } |
There was a problem hiding this comment.
The parameter names are one level off: this method receives the tool folder (the caller passes toolFolder), not an edition folder - and the loop variable that actually is the edition folder is called subfolder. In the review of #1957 the request was to name these editionFolder and versionFolder.
| /** | |
| * This method discovers all installed editions of a tool at $IDE_ROOT/_ide/software/<repository>/<tool> and saves them to the edition list of the tool. | |
| * Installed versions of the edition are then recursively discovered. | |
| * | |
| * @param editionFolder The folder where the editions are saved in ($IDE_ROOT/_ide/software/<repository>/<tool>). | |
| * @param tool The respective tool for which we are discovering editions. | |
| */ | |
| private void discoverInstalledEditions(Path editionFolder, CleanupIdeTool tool) { | |
| List<Path> subfolders = this.context.getFileAccess().listChildren(editionFolder, Files::isDirectory); | |
| for (Path subfolder : subfolders) { | |
| CleanupIdeToolEdition edition = new CleanupIdeToolEdition(subfolder.getFileName().toString(), this.context.getFileAccess().toRealPath(subfolder)); | |
| tool.getEditions().add(edition); | |
| discoverInstalledVersions(subfolder, edition); | |
| } | |
| } | |
| /** | |
| * This method discovers all installed editions of a tool at $IDE_ROOT/_ide/software/<repository>/<tool> 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/<repository>/<tool>). | |
| * @param tool The respective tool for which we are discovering editions. | |
| */ | |
| private void discoverInstalledEditions(Path toolFolder, CleanupIdeTool tool) { | |
| List<Path> editionFolders = this.context.getFileAccess().listChildren(toolFolder, Files::isDirectory); | |
| for (Path editionFolder : editionFolders) { | |
| Path editionPath = this.context.getFileAccess().toRealPath(editionFolder); | |
| CleanupIdeToolEdition edition = new CleanupIdeToolEdition(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/<repository>/<tool>/<edition> and saves them to the version | ||
| * list of the edition. | ||
| * | ||
| * @param versionFolder The folder where the versions are saved in ($IDE_ROOT/_ide/software/<repository>/<tool>/<edition>). | ||
| * @param edition The respective edition for which we are discovering versions. | ||
| */ | ||
| private void discoverInstalledVersions(Path versionFolder, CleanupIdeToolEdition edition) { | ||
| List<Path> subfolders = this.context.getFileAccess().listChildren(versionFolder, Files::isDirectory); | ||
| for (Path subfolder : subfolders) { | ||
| CleanupIdeToolEditionVersion version = new CleanupIdeToolEditionVersion(subfolder.getFileName().toString(), | ||
| this.context.getFileAccess().toRealPath(subfolder)); | ||
| edition.getVersions().add(version); | ||
| } | ||
| } |
There was a problem hiding this comment.
Same here: the parameter is the edition folder, not a version folder.
| /** | |
| * This method discovers all installed versions of an edition of a tool at $IDE_ROOT/_ide/software/<repository>/<tool>/<edition> and saves them to the version | |
| * list of the edition. | |
| * | |
| * @param versionFolder The folder where the versions are saved in ($IDE_ROOT/_ide/software/<repository>/<tool>/<edition>). | |
| * @param edition The respective edition for which we are discovering versions. | |
| */ | |
| private void discoverInstalledVersions(Path versionFolder, CleanupIdeToolEdition edition) { | |
| List<Path> subfolders = this.context.getFileAccess().listChildren(versionFolder, Files::isDirectory); | |
| for (Path subfolder : subfolders) { | |
| CleanupIdeToolEditionVersion version = new CleanupIdeToolEditionVersion(subfolder.getFileName().toString(), | |
| this.context.getFileAccess().toRealPath(subfolder)); | |
| edition.getVersions().add(version); | |
| } | |
| } | |
| /** | |
| * This method discovers all installed versions of an edition of a tool at $IDE_ROOT/_ide/software/<repository>/<tool>/<edition> and saves them to the version | |
| * list of the edition. | |
| * | |
| * @param editionFolder The folder where the versions are saved in ($IDE_ROOT/_ide/software/<repository>/<tool>/<edition>). | |
| * @param edition The respective edition for which we are discovering versions. | |
| */ | |
| private void discoverInstalledVersions(Path editionFolder, CleanupIdeToolEdition edition) { | |
| List<Path> versionFolders = this.context.getFileAccess().listChildren(editionFolder, Files::isDirectory); | |
| for (Path versionFolder : versionFolders) { | |
| Path versionPath = this.context.getFileAccess().toRealPath(versionFolder); | |
| CleanupIdeToolEditionVersion version = new CleanupIdeToolEditionVersion(versionFolder.getFileName().toString(), versionPath); | |
| edition.getVersions().add(version); | |
| } | |
| } |
| // Ask for confirmation. Automatically confirmed if the global force option is provided. | ||
| try { | ||
| this.context.askToContinue("Do you want to continue?"); | ||
|
|
||
| } catch (CliAbortException e) { | ||
| LOG.info("Installed Tools will not be deleted."); | ||
| return; | ||
| } |
There was a problem hiding this comment.
This is the only place in the main sources that catches CliAbortException. Swallowing it means ide cleanup exits with code 0 when the user answers "no", instead of ProcessResult.ABORT a calling script cannot distinguish "aborted by user" from "completed". Letting it propagate gives the standard Aborted by end-user. handling for free.
| // Ask for confirmation. Automatically confirmed if the global force option is provided. | |
| try { | |
| this.context.askToContinue("Do you want to continue?"); | |
| } catch (CliAbortException e) { | |
| LOG.info("Installed Tools will not be deleted."); | |
| return; | |
| } | |
| // Ask for confirmation. Automatically confirmed in batch mode with the global force option. | |
| this.context.askToContinue("Do you want to continue?"); |
The import of com.devonfw.tools.ide.cli.CliAbortException in line 11 then becomes unused and has to be removed, otherwise checkstyle fails.
| 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.editionName + "\n" + logOutputVersion; | ||
| editionsDeleted++; | ||
| totalEditionsDeleted++; | ||
| } |
There was a problem hiding this comment.
editionsDeleted / totalEditionsDeleted are incremented whenever any version of the edition is deleted, so they count "affected" rather than "deleted" editions. For a tool with edition A (1 of 2 versions deleted) and edition B (nothing deleted) the output says "1 more edition(s) of this tool will not be deleted", although in fact both editions are kept. The Summary: line in 268 has the same problem.
| 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.editionName + "\n" + logOutputVersion; | |
| editionsDeleted++; | |
| totalEditionsDeleted++; | |
| } | |
| 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.editionName + "\n" + logOutputVersion; | |
| if (edition.isDelete()) { | |
| editionsDeleted++; | |
| totalEditionsDeleted++; | |
| } | |
| } |
totalToolsDeleted in line 260 needs the same guard with tool.isDelete(). Note this interacts with the suggestion to keep the delete flag only on CleanupIdeToolEditionVersion - in that design the counters would be derived from "all children deleted" instead.
|
|
||
| // 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")) { |
There was a problem hiding this comment.
IdeContext.FOLDER_UNDERSCORE_IDE exists for this - using the constant also makes the usage findable from the IDE. IdeContext is already imported.
| if (ideasyProject.getFileName().toString().equals("_ide")) { | |
| if (ideasyProject.getFileName().toString().equals(IdeContext.FOLDER_UNDERSCORE_IDE)) { |
(Becomes obsolete if the central IdeContext.findProjects() suggested above is implemented.)
- add common abstract base class for installed software items - centralize software item name and path - rename cleanup model classes for reuse
- keep deletion state only on software versions - delete unused versions before empty parent folders - remove redundant deletion state from tools and editions
- add reusable project discovery to IdeContext - exclude non project folders centrally - reuse project discovery in cleanup and GUI
This PR fixes #1953
This PR completes and supersedes #1957 after the original author left the team.
Implemented changes:
-f/--forcemode instead of introducing a cleanup-specific force option.--force-deletehelp entries.commandlet.cleanuppackage._ide/software, includingdefault,maven, and custom repository IDs.Contents/MacOS.software/<tool>software/extra/<tool>software/extra/<tool>/<name>Testing instructions
Please add conscise, understandable instructions on how a reviewer can test/verify the functionality of your contribution here:
Related
Checklist for this PR
Make sure everything is checked before merging this PR. For further info please also see
our DoD.
mvn clean testlocally all tests pass and build is successful#«issue-id»: «brief summary»(e.g.#921: fixed setup.bat). If no issue ID exists, title only.In Progressand assigned to you or there is no issue (might happen for very small PRs)with
internalChecklist for tool commandlets
Have you added a new
«tool»as commandlet? There are the following additional checks:«tool»«TOOL»_VERSIONand«TOOL»_EDITIONare honored by your commandlet