From 4dcf9b6033513b1c54e5898ca8ee756c8efe76c8 Mon Sep 17 00:00:00 2001 From: krystynaShatkovska Date: Thu, 27 Aug 2026 09:16:51 +0200 Subject: [PATCH] #2372: Add --retention-delay option to ide cleanup Adds a --retention-delay option to the cleanup commandlet to delete stale files that have not been modified within a configurable period. Files are scanned recursively under $IDE_HOME/updates, $IDE_ROOT/_ide/tmp and ~/Downloads/ide. The option accepts a time-based ISO-8601 duration (e.g. P30D) and defaults to 1 year (365 days) if not provided. Empty folders left behind after deleting stale files are removed, while the scanned roots themselves are kept. --- CHANGELOG.adoc | 1 + .../commandlet/cleanup/CleanupCommandlet.java | 198 +++++++++++++++++- cli/src/main/resources/nls/Help.properties | 1 + cli/src/main/resources/nls/Help_de.properties | 1 + .../ide/commandlet/CleanupCommandletTest.java | 163 ++++++++++++++ documentation/software.adoc | 2 + 6 files changed, 365 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 12ec7782fb..e7cd4b8d4f 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -25,6 +25,7 @@ Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/2251[#2251]: Provide generic uninstall support for globally installed tools (windows) * https://github.com/devonfw/IDEasy/issues/1135[#1135]: Fix PowerShell env variable initialization on Windows by sourcing functions from the PowerShell profile * https://github.com/devonfw/IDEasy/issues/741[#741]: Add a warning message for legacy devonfw-ide settings users +* https://github.com/devonfw/IDEasy/issues/2372[#2372]: Added `--retention-delay` option to `ide cleanup` to delete stale files in `updates`, `_ide/tmp` and `Downloads/ide` The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/49?closed=1[milestone 2026.08.002]. diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/cleanup/CleanupCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/cleanup/CleanupCommandlet.java index d086693ad6..c562cf787e 100644 --- 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 @@ -2,14 +2,21 @@ import java.nio.file.Files; import java.nio.file.Path; +import java.time.Duration; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.devonfw.tools.ide.cli.CliException; 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.property.StringProperty; import com.devonfw.tools.ide.step.Step; import com.devonfw.tools.ide.tool.mvn.MvnRepository; import com.devonfw.tools.ide.tool.repository.ToolRepository; @@ -21,6 +28,12 @@ public class CleanupCommandlet extends Commandlet { private static final Logger LOG = LoggerFactory.getLogger(CleanupCommandlet.class); + /** The default retention period of stale files. Stale files are considered stale after 1 year (365 days) of inactivity. */ + public static final Duration DEFAULT_RETENTION_DELAY = Duration.ofDays(365); + + /** The {@link StringProperty} of the {@code --retention-delay} option. */ + private final StringProperty retentionDelay; + /** * Constructor. * @@ -30,6 +43,7 @@ public CleanupCommandlet(IdeContext context) { super(context); addKeyword(getName()); + this.retentionDelay = add(new StringProperty("--retention-delay", false, null)); } @Override @@ -49,6 +63,8 @@ protected void doRun() { LOG.debug("Start cleanup commandlet"); + Duration retentionDelay = getRetentionDelay(); + InstalledSoftware installedSoftware = new InstalledSoftware(); Step step = this.context.newStep("Identify unused software"); @@ -56,14 +72,50 @@ protected void doRun() { logSoftwareToBeDeleted(installedSoftware.getTools()); - if (hasSoftwareToDelete(installedSoftware.getTools())) { + List staleRoots = new ArrayList<>(); + List staleFiles = new ArrayList<>(); + if (this.context.getIdeHome() != null) { + Step staleStep = this.context.newStep("Identify stale files"); + staleStep.run(() -> { + staleRoots.addAll(getStaleFileRoots()); + discoverStaleFiles(staleRoots, staleFiles, retentionDelay); + }, true); + logStaleFilesToBeDeleted(staleFiles, retentionDelay); + } + + boolean hasStaleFiles = !staleFiles.isEmpty(); + if (hasSoftwareToDelete(installedSoftware.getTools()) || hasStaleFiles) { this.context.askToContinue("Do you want to continue?"); deleteUnusedSoftware(installedSoftware.getTools()); + if (hasStaleFiles) { + deleteStaleFiles(staleFiles, staleRoots); + } } LOG.debug("Finished cleanup commandlet"); } + /** + * Determines the retention delay to use. + * + * @return the retention delay, {@link #DEFAULT_RETENTION_DELAY} if the {@code --retention-delay} option was not provided. + * @throws CliException if the provided value is not a valid ISO-8601 time-based duration. + */ + private Duration getRetentionDelay() { + + String value = this.retentionDelay.getValueAsString(); + if (value == null) { + return DEFAULT_RETENTION_DELAY; + } + try { + return Duration.parse(value); + } catch (DateTimeParseException e) { + throw new CliException( + "Invalid value '" + value + "' for --retention-delay. Please provide a time-based ISO-8601 duration such as P30D or PT2H30M.", + e); + } + } + /** * Discovers installed and unused software. * @@ -356,4 +408,148 @@ private int deleteFolder(Path path) { } return 0; } + + /** + * Discovers stale files, i.e. files that have not been modified within the given retention delay, in the given root folders. + * + * @param roots the folders to scan. + * @param staleFiles the list to populate with the stale files. + * @param retentionDelay the age after which a file is considered stale. + */ + private void discoverStaleFiles(List roots, List staleFiles, Duration retentionDelay) { + + for (Path root : roots) { + discoverStaleFilesRecursive(root, retentionDelay, staleFiles); + } + } + + /** + * Recursively collects the stale files below the given folder, i.e. all files that are older than the retention delay. + * + * @param folder the folder to scan. + * @param retentionDelay the age after which a file is considered stale. + * @param staleFiles the list to populate with the stale files. + */ + private void discoverStaleFilesRecursive(Path folder, Duration retentionDelay, List staleFiles) { + + if (!Files.isDirectory(folder)) { + return; + } + + for (Path child : this.context.getFileAccess().listChildren(folder, child -> true)) { + if (Files.isDirectory(child)) { + discoverStaleFilesRecursive(child, retentionDelay, staleFiles); + } else if (isStale(child, retentionDelay)) { + staleFiles.add(child); + } + } + } + + /** + * Determines the folders scanned for stale files: the IDEasy updates folder, the temporary folder, the download cache and the legacy download + * cache under {@code ~/Downloads/ide}. + * + * @return the list of root folders to scan, excluding folders that do not exist. + */ + private List getStaleFileRoots() { + + List roots = new ArrayList<>(); + + addStaleFileRoot(roots, this.context.getIdeHome().resolve(IdeContext.FOLDER_UPDATES)); + addStaleFileRoot(roots, this.context.getTempPath()); + Path downloadPath = this.context.getDownloadPath(); + addStaleFileRoot(roots, downloadPath); + + // older versions kept the download cache under ~/Downloads/ide - scan that location too if it is distinct and still exists + Path legacy = this.context.getUserHome().resolve(IdeContext.FOLDER_DOWNLOADS).resolve("ide"); + if (!legacy.equals(downloadPath)) { + addStaleFileRoot(roots, legacy); + } + + return roots; + } + + /** + * Adds the given folder to the list of scanned roots if it exists. + * + * @param roots the list of root folders to populate. + * @param root the candidate root folder. + */ + private void addStaleFileRoot(List roots, Path root) { + + if (root != null && Files.exists(root)) { + roots.add(root); + } + } + + /** + * Determines whether the given file is older than the given retention delay. + * + * @param file the file to check. + * @param retentionDelay the age after which the file is considered stale. + * @return {@code true} if the file exists and is older than the retention delay. + */ + private boolean isStale(Path file, Duration retentionDelay) { + + Duration age = this.context.getFileAccess().getFileAge(file); + return (age != null) && age.compareTo(retentionDelay) > 0; + } + + /** + * Logs a summary of the stale files to be deleted. + * + * @param staleFiles the stale files to report. + * @param retentionDelay the age that the stale files exceed. + */ + private void logStaleFilesToBeDeleted(List staleFiles, Duration retentionDelay) { + + if (staleFiles.isEmpty()) { + LOG.info("No stale files older than {} will be deleted.", retentionDelay); + } else { + for (Path staleFile : staleFiles) { + LOG.info("\t - {} will be deleted", staleFile); + } + LOG.info("Summary: {} stale file(s) older than {} will be deleted.", staleFiles.size(), retentionDelay); + } + } + + /** + * Deletes the given stale files and removes the parent folders that became empty as a result. Folders above the scanned roots are never removed. + * + * @param staleFiles the stale files to delete. + * @param roots the folders that were scanned, which must not be removed themselves. + */ + private void deleteStaleFiles(List staleFiles, List roots) { + + int failedDeletion = 0; + + for (Path staleFile : staleFiles) { + if (Files.exists(staleFile)) { + LOG.debug("Deleting stale file {}", staleFile); + failedDeletion += deleteFolder(staleFile); + } + } + + // Remove the folders that became empty after deleting the stale files, walking up from each deleted file but never removing the scanned roots + // themselves. + Set prunedFolders = new HashSet<>(); + for (Path staleFile : staleFiles) { + Path folder = staleFile.getParent(); + while (folder != null && !prunedFolders.contains(folder) && !roots.contains(folder)) { + if (!isEmptyFolder(folder)) { + break; + } + LOG.debug("Deleting empty folder {}", folder); + prunedFolders.add(folder); + failedDeletion += deleteFolder(folder); + folder = folder.getParent(); + } + } + + if (failedDeletion > 0) { + LOG.warn("Stale files have been deleted.\nFailed to delete {} file(s) or folder(s). Please check the log for details.", failedDeletion); + } else { + IdeLogLevel.SUCCESS.log(LOG, "Stale files have been deleted successfully."); + } + } } diff --git a/cli/src/main/resources/nls/Help.properties b/cli/src/main/resources/nls/Help.properties index b13135299e..d848785a11 100644 --- a/cli/src/main/resources/nls/Help.properties +++ b/cli/src/main/resources/nls/Help.properties @@ -13,6 +13,7 @@ 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.cleanup.opt.--retention-delay=the retention period of files in the 'updates', '_ide/tmp' and 'Downloads/ide' folders, i.e. files that were not modified within this period are deleted as stale. A time-based ISO-8601 duration (e.g. 'P30D' for 30 days or 'PT2H30M' for 2 hours and 30 minutes). Defaults to 1 year (365 days) if not provided. 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. diff --git a/cli/src/main/resources/nls/Help_de.properties b/cli/src/main/resources/nls/Help_de.properties index b87c187278..4d897bf539 100644 --- a/cli/src/main/resources/nls/Help_de.properties +++ b/cli/src/main/resources/nls/Help_de.properties @@ -13,6 +13,7 @@ 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.cleanup.opt.--retention-delay=die Aufbewahrungsdauer von Dateien in den Ordnern 'updates', '_ide/tmp' und 'Downloads/ide', d.h. Dateien, die innerhalb dieses Zeitraums nicht modifiziert wurden, werden als veraltet gelöscht. Eine zeitbasierte ISO-8601-Dauer (z. B. 'P30D' für 30 Tage oder 'PT2H30M' für 2 Stunden und 30 Minuten). Standardmäßig 1 Jahr (365 Tage), wenn nicht angegeben. 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 index e032ae9cce..254485d766 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/commandlet/CleanupCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/CleanupCommandletTest.java @@ -1,16 +1,22 @@ package com.devonfw.tools.ide.commandlet; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.time.Duration; import org.junit.jupiter.api.Test; +import com.devonfw.tools.ide.cli.CliException; 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; +import com.devonfw.tools.ide.property.StringProperty; /** * Test of {@link CleanupCommandlet}. @@ -183,6 +189,163 @@ void testCleanupSkipsConfirmationInBatchForceMode() throws IOException { assertThat(context).logAtSuccess().hasMessage("Unused tools have been deleted successfully."); } + /** + * Tests that files older than the {@code --retention-delay} are deleted from the updates, {@code _ide/tmp} and {@code ~/Downloads/ide} folders + * while newer files are kept. + * + * @throws IOException if the test setup cannot be created. + */ + @Test + void testCleanupDeletesStaleFilesOlderThanRetentionDelay() throws IOException { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + Duration retentionDelay = Duration.ofDays(1); + + Path staleUpdatesFile = createStaleFile(context.getIdeHome().resolve(IdeContext.FOLDER_UPDATES).resolve("stale-updates.bin"), + retentionDelay); + Path staleTmpFile = createStaleFile(context.getTempPath().resolve("stale-tmp.bin"), retentionDelay); + Path staleDownloadsFile = createStaleFile( + context.getUserHome().resolve(IdeContext.FOLDER_DOWNLOADS).resolve("ide").resolve("stale-download.bin"), retentionDelay); + + Path freshUpdatesFile = createFreshFile(context.getIdeHome().resolve(IdeContext.FOLDER_UPDATES).resolve("fresh-updates.bin"), + retentionDelay); + Path freshTmpFile = createFreshFile(context.getTempPath().resolve("fresh-tmp.bin"), retentionDelay); + Path freshDownloadsFile = createFreshFile( + context.getUserHome().resolve(IdeContext.FOLDER_DOWNLOADS).resolve("ide").resolve("fresh-download.bin"), retentionDelay); + + context.setAnswers("yes"); + setRetentionDelay(context, "P1D"); + CleanupCommandlet cleanup = context.getCommandletManager().getCommandlet(CleanupCommandlet.class); + + // act + cleanup.run(); + + // assert + assertThat(staleUpdatesFile).as("Stale file in updates should be deleted").doesNotExist(); + assertThat(staleTmpFile).as("Stale file in _ide/tmp should be deleted").doesNotExist(); + assertThat(staleDownloadsFile).as("Stale file in ~/Downloads/ide should be deleted").doesNotExist(); + + assertThat(freshUpdatesFile).as("Fresh file in updates must not be deleted").exists(); + assertThat(freshTmpFile).as("Fresh file in _ide/tmp must not be deleted").exists(); + assertThat(freshDownloadsFile).as("Fresh file in ~/Downloads/ide must not be deleted").exists(); + + assertThat(context).logAtSuccess().hasMessage("Stale files have been deleted successfully."); + } + + /** + * Tests that empty sub-folders below the scanned folders are removed after their stale contents have been deleted, while the scanned folders + * themselves are kept. + * + * @throws IOException if the test setup cannot be created. + */ + @Test + void testCleanupRemovesEmptyFoldersAfterDeletingStaleFiles() throws IOException { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + Duration retentionDelay = Duration.ofDays(1); + + Path subFolder = context.getIdeHome().resolve(IdeContext.FOLDER_UPDATES).resolve("nested").resolve("deep"); + Files.createDirectories(subFolder); + Path staleFile = createStaleFile(subFolder.resolve("stale.bin"), retentionDelay); + + context.setAnswers("yes"); + setRetentionDelay(context, "P1D"); + CleanupCommandlet cleanup = context.getCommandletManager().getCommandlet(CleanupCommandlet.class); + + // act + cleanup.run(); + + // assert + assertThat(staleFile).as("The stale file should be deleted").doesNotExist(); + assertThat(subFolder).as("The now-empty sub-folder should be removed").doesNotExist(); + Path updatesRoot = context.getIdeHome().resolve(IdeContext.FOLDER_UPDATES); + assertThat(updatesRoot).as("The scanned root folder itself must not be removed").exists(); + } + + /** + * Tests that an invalid {@code --retention-delay} value results in a {@link CliException}. + */ + @Test + void testCleanupRejectsInvalidRetentionDelay() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setAnswers("yes"); + setRetentionDelay(context, "PT6M10D"); + CleanupCommandlet cleanup = context.getCommandletManager().getCommandlet(CleanupCommandlet.class); + + // act + assertThatThrownBy(cleanup::run).isInstanceOf(CliException.class).hasMessageContaining("Invalid value 'PT6M10D' for --retention-delay"); + } + + /** + * Tests that stale files are not deleted when the {@code --retention-delay} option is not provided, i.e. the default retention delay (1 year) + * applies and the recently created test files are kept. + * + * @throws IOException if the test setup cannot be created. + */ + @Test + void testCleanupKeepsFilesWithinDefaultRetentionDelay() throws IOException { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + Path recentFile = createFreshFile(context.getTempPath().resolve("recent.bin"), CleanupCommandlet.DEFAULT_RETENTION_DELAY); + + context.setAnswers("yes"); + CleanupCommandlet cleanup = context.getCommandletManager().getCommandlet(CleanupCommandlet.class); + + // act + cleanup.run(); + + // assert + assertThat(recentFile).as("Files within the default retention delay must not be deleted").exists(); + } + + /** + * Creates a file with a modification time older than the given retention delay. + * + * @param file the file to create. + * @param retentionDelay the age the file must exceed. + * @return the created file. + * @throws IOException if the file cannot be created. + */ + private Path createStaleFile(Path file, Duration retentionDelay) throws IOException { + + Files.createDirectories(file.getParent()); + Files.writeString(file, "stale content"); + Files.setLastModifiedTime(file, FileTime.fromMillis(System.currentTimeMillis() - retentionDelay.toMillis() - 1_000_000L)); + return file; + } + + /** + * Creates a file with a modification time newer than the given retention delay. + * + * @param file the file to create. + * @param retentionDelay the age the file must not exceed. + * @return the created file. + * @throws IOException if the file cannot be created. + */ + private Path createFreshFile(Path file, Duration retentionDelay) throws IOException { + + Files.createDirectories(file.getParent()); + Files.writeString(file, "fresh content"); + Files.setLastModifiedTime(file, FileTime.fromMillis(System.currentTimeMillis() - retentionDelay.toMillis() / 2)); + return file; + } + + /** + * Sets the value of the {@code --retention-delay} option of the cleanup commandlet. + * + * @param context the test context. + * @param value the value to set. + */ + private void setRetentionDelay(IdeTestContext context, String value) { + + ((StringProperty) context.getCommandletManager().getCommandlet(CleanupCommandlet.class).getOption("--retention-delay")).setValue(value); + } + /** * Creates an installed software version with the structure {@code _ide/software////}. * diff --git a/documentation/software.adoc b/documentation/software.adoc index 4dd33184c4..2ca5303ca4 100644 --- a/documentation/software.adoc +++ b/documentation/software.adoc @@ -17,6 +17,8 @@ This has the following benefits: However, we keep previous tool version on updates what can also waste disc-space. Therefore, you can run `ide cleanup` to find and release old tool versions and free disc-space. +In addition, `ide cleanup` deletes stale files in the `$IDE_HOME/updates`, `$IDE_ROOT/_ide/tmp` and `~/Downloads/ide` folders (recursively). A file is considered stale if it has not been modified for a configurable period. This period can be set via the `--retention-delay` option using a time-based ISO-8601 duration, e.g. `ide cleanup --retention-delay=P30D`. If the option is not provided, the default of 1 year (365 days) is used. + == Extra Tool Installations Assuming you needs multiple versions of the same tool (SDK), you can make IDEasy install additional versions.