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
161 changes: 161 additions & 0 deletions core/Command/App/Purge.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OC\Core\Command\App;

use OC\App\AppDataPurger;
use OC\App\AppLeftovers;
use OC\Core\Command\Base;
use OCP\Util;
use Override;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class Purge extends Base {
public function __construct(
private readonly AppDataPurger $purger,
) {
parent::__construct();
}

#[Override]
protected function configure(): void {
$this
->setName('app:purge')
->setDescription('List or delete the data that disabled and removed apps left behind')
->setHelp(
"Without an app id, lists the apps that are not enabled but still have data.\n"
. "With one, shows what that app left behind: its database tables, app config, user preferences,\n"
. "background jobs, migration records and app data folder. Nothing is deleted without --force.\n"
. "\n"
. "An app's tables are found from its migrations, so purge an app before removing its code:\n"
. " occ app:disable <app-id>\n"
. " occ app:purge <app-id> --force\n"
. " occ app:remove --keep-data <app-id>\n"
. "\n"
. "Files the app saved in users' own folders are not touched. Back up the database first."
)
->addArgument('app-id', InputArgument::OPTIONAL, 'the app whose data to show or delete')
->addOption('force', 'f', InputOption::VALUE_NONE, 'delete it, rather than only showing what would be deleted');
parent::configure();
}

#[Override]
protected function execute(InputInterface $input, OutputInterface $output): int {
$appId = $input->getArgument('app-id');
$plain = $input->getOption('output') === self::OUTPUT_FORMAT_PLAIN;

if ($appId === null) {
$found = $this->purger->findAppsWithLeftovers();
if (!$plain) {
$this->writeArrayInOutputFormat($input, $output, array_map(fn (AppLeftovers $l) => $l->toArray(), $found));
} elseif ($found === []) {
$output->writeln('<info>No disabled or removed app has data left behind.</info>');
} else {
foreach ($found as $leftovers) {
$code = $leftovers->codePresent ? 'disabled' : 'removed';
$output->writeln("{$leftovers->appId} ($code): " . $this->summarize($leftovers));
}
$output->writeln('');
$output->writeln('Run occ app:purge <app-id> to see the details for one app.');
}
return self::SUCCESS;
}

$appId = (string)$appId;
$refusal = $this->purger->getRefusal($appId);
if ($refusal !== null) {
$output->writeln("<error>$refusal</error>");
return self::FAILURE;
}

try {
$leftovers = $this->purger->getLeftovers($appId);
} catch (\RuntimeException $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
$output->writeln('Nothing was deleted.');
return self::FAILURE;
}
$force = (bool)$input->getOption('force');

if (!$plain) {
if ($force && !$leftovers->isEmpty()) {
$this->purger->purge($leftovers);
}
$this->writeArrayInOutputFormat($input, $output, $leftovers->toArray() + ['deleted' => $force && !$leftovers->isEmpty()]);
return self::SUCCESS;
}

if ($leftovers->isEmpty()) {
$output->writeln("<info>App '$appId' has no data left behind.</info>");
return self::SUCCESS;
}

$output->writeln(($force ? 'Deleting' : 'Found') . " what app '$appId' left behind:");
$this->describe($leftovers, $output);

if (!$leftovers->codePresent) {
$output->writeln('');
$output->writeln("<comment>The app's code is gone, so its tables cannot be found. To delete them too, install it again,"
. ' disable it, and run this before removing it. occ db:schema:check -v lists tables no app accounts for.</comment>');
}

if (!$force) {
$output->writeln('');
$output->writeln('Nothing was deleted. Run again with --force to delete all of it.');
return self::SUCCESS;
}

$this->purger->purge($leftovers);
$output->writeln('');
$output->writeln("<info>Deleted what app '$appId' left behind.</info>");
return self::SUCCESS;
}

private function describe(AppLeftovers $leftovers, OutputInterface $output): void {
foreach ($leftovers->tables as $table) {
$output->writeln(" - table $table");
}
$lines = [
[$leftovers->configKeys, 'app config value', 'app config values'],
[$leftovers->userPreferences, 'user preference', 'user preferences'],
[$leftovers->backgroundJobs, 'background job', 'background jobs'],
[$leftovers->migrations, 'migration record', 'migration records'],
];
foreach ($lines as [$count, $one, $many]) {
if ($count > 0) {
$output->writeln(" - $count " . ($count === 1 ? $one : $many));
}
}
if ($leftovers->appDataSize !== null) {
$output->writeln(' - app data folder, ' . Util::humanFileSize($leftovers->appDataSize));
}
}

private function summarize(AppLeftovers $leftovers): string {
$parts = [];
$tables = count($leftovers->tables);
foreach ([
[$tables, 'table', 'tables'],
[$leftovers->configKeys, 'config value', 'config values'],
[$leftovers->userPreferences, 'user preference', 'user preferences'],
[$leftovers->backgroundJobs, 'background job', 'background jobs'],
[$leftovers->migrations, 'migration record', 'migration records'],
] as [$count, $one, $many]) {
if ($count > 0) {
$parts[] = "$count " . ($count === 1 ? $one : $many);
}
}
if ($leftovers->appDataSize !== null) {
$parts[] = 'app data ' . Util::humanFileSize($leftovers->appDataSize);
}
return implode(', ', $parts);
}
}
1 change: 1 addition & 0 deletions core/Command/App/Remove.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ protected function configure(): void {
. "By default, this command runs the app's uninstall steps (which may delete data) and then removes the app files.\n"
. "Use `--keep-data` to skip uninstall steps and preserve app data (database tables, configuration, and stored files).\n"
. "Note: Some apps may still preserve data either way, depending on their uninstall implementation.\n"
. "To delete what an app leaves behind, disable it and run `occ app:purge` before removing it.\n"
)
->addArgument(
'app-id',
Expand Down
2 changes: 2 additions & 0 deletions core/register_command.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use OC\Core\Command\App\GetPath;
use OC\Core\Command\App\Install;
use OC\Core\Command\App\ListApps;
use OC\Core\Command\App\Purge;
use OC\Core\Command\App\Remove;
use OC\Core\Command\App\Update;
use OC\Core\Command\Background\Delete;
Expand Down Expand Up @@ -146,6 +147,7 @@
$application->addCommand(Server::get(GetPath::class));
$application->addCommand(Server::get(ListApps::class));
$application->addCommand(Server::get(Remove::class));
$application->addCommand(Server::get(Purge::class));
$application->addCommand(Server::get(Update::class));

$application->addCommand(Server::get(Cleanup::class));
Expand Down
3 changes: 3 additions & 0 deletions lib/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -1281,6 +1281,8 @@
'OC\\AppFramework\\Utility\\TimeFactory' => $baseDir . '/lib/private/AppFramework/Utility/TimeFactory.php',
'OC\\AppScriptDependency' => $baseDir . '/lib/private/AppScriptDependency.php',
'OC\\AppScriptSort' => $baseDir . '/lib/private/AppScriptSort.php',
'OC\\App\\AppDataPurger' => $baseDir . '/lib/private/App/AppDataPurger.php',
'OC\\App\\AppLeftovers' => $baseDir . '/lib/private/App/AppLeftovers.php',
'OC\\App\\AppManager' => $baseDir . '/lib/private/App/AppManager.php',
'OC\\App\\AppStore\\AppNotFoundException' => $baseDir . '/lib/private/App/AppStore/AppNotFoundException.php',
'OC\\App\\AppStore\\AppStoreLinkVisibility' => $baseDir . '/lib/private/App/AppStore/AppStoreLinkVisibility.php',
Expand Down Expand Up @@ -1464,6 +1466,7 @@
'OC\\Core\\Command\\App\\GetPath' => $baseDir . '/core/Command/App/GetPath.php',
'OC\\Core\\Command\\App\\Install' => $baseDir . '/core/Command/App/Install.php',
'OC\\Core\\Command\\App\\ListApps' => $baseDir . '/core/Command/App/ListApps.php',
'OC\\Core\\Command\\App\\Purge' => $baseDir . '/core/Command/App/Purge.php',
'OC\\Core\\Command\\App\\Remove' => $baseDir . '/core/Command/App/Remove.php',
'OC\\Core\\Command\\App\\Update' => $baseDir . '/core/Command/App/Update.php',
'OC\\Core\\Command\\Background\\Delete' => $baseDir . '/core/Command/Background/Delete.php',
Expand Down
3 changes: 3 additions & 0 deletions lib/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -1322,6 +1322,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\AppFramework\\Utility\\TimeFactory' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Utility/TimeFactory.php',
'OC\\AppScriptDependency' => __DIR__ . '/../../..' . '/lib/private/AppScriptDependency.php',
'OC\\AppScriptSort' => __DIR__ . '/../../..' . '/lib/private/AppScriptSort.php',
'OC\\App\\AppDataPurger' => __DIR__ . '/../../..' . '/lib/private/App/AppDataPurger.php',
'OC\\App\\AppLeftovers' => __DIR__ . '/../../..' . '/lib/private/App/AppLeftovers.php',
'OC\\App\\AppManager' => __DIR__ . '/../../..' . '/lib/private/App/AppManager.php',
'OC\\App\\AppStore\\AppNotFoundException' => __DIR__ . '/../../..' . '/lib/private/App/AppStore/AppNotFoundException.php',
'OC\\App\\AppStore\\AppStoreLinkVisibility' => __DIR__ . '/../../..' . '/lib/private/App/AppStore/AppStoreLinkVisibility.php',
Expand Down Expand Up @@ -1505,6 +1507,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Core\\Command\\App\\GetPath' => __DIR__ . '/../../..' . '/core/Command/App/GetPath.php',
'OC\\Core\\Command\\App\\Install' => __DIR__ . '/../../..' . '/core/Command/App/Install.php',
'OC\\Core\\Command\\App\\ListApps' => __DIR__ . '/../../..' . '/core/Command/App/ListApps.php',
'OC\\Core\\Command\\App\\Purge' => __DIR__ . '/../../..' . '/core/Command/App/Purge.php',
'OC\\Core\\Command\\App\\Remove' => __DIR__ . '/../../..' . '/core/Command/App/Remove.php',
'OC\\Core\\Command\\App\\Update' => __DIR__ . '/../../..' . '/core/Command/App/Update.php',
'OC\\Core\\Command\\Background\\Delete' => __DIR__ . '/../../..' . '/core/Command/Background/Delete.php',
Expand Down
Loading